feat: scaffold OpenNext Cloudflare build path#86
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Task Reference: [MYMR-164]
Adds the OpenNext Cloudflare build path so Mymir can deploy to Workers while keeping the self-host Docker / Postgres build working from the same repo. The two builds are gated by
DEPLOY_TARGET=cloudflare; each output skips what the other needs.What this PR ships:
open-next.config.ts(R2 incremental cache, DO queue, D1 tag cache) andwrangler.jsoncwithASSETS,WORKER_SELF_REFERENCE, KV (AUTH_KV), R2 (NEXT_INC_CACHE_R2_BUCKET), D1 (NEXT_TAG_CACHE_D1), and theMYMIR_BROKERDurable Object. Placeholder IDs land at the syntactic minimum wrangler accepts; MYMR-165 fills them in.lib/db/_driver.{node,workers,ts}so the Workers bundle imports only@neondatabase/serverlessand the self-host bundle imports onlypostgres-js. Routing happens at webpack module-resolution viaNormalModuleReplacementPlugininnext.config.ts. The existingappDb/authDb/serviceRoleDbproxy surface stays — call sites are unchanged.lib/db/request-scope.workers.tsexportswithRequestDb()that builds fresh Neon pools for the three roles, seeds an AsyncLocalStorage frame so the connection proxies resolve to them, and schedulesctx.waitUntil(pool.end())so socket teardown does not block the response. Self-host falls back to a globalThis cache; the proxy surface picks the right path automatically.lib/realtime/_broker.{node,workers,ts}mirroring the driver split, pluslib/realtime/broker-do.tsshipping aMymirBrokerDurable Object skeleton. The DO is exported fromworker.jsvia thescripts/postbuild-cf.tspatcher because OpenNext exposes no user-DO extension point.build:cf,preview:cf,deploy:cf,cf-typegeninpackage.json.DEPLOY_TARGET=cloudflareis set on every command in the chain — setting it only on the leading command would lose it for OpenNext's internalnext buildpass.proxy.tsis locked to the Node.js runtime which workerd rejects; OpenNext needs Edge middleware. The CSP nonce generator swaps fromBuffertobtoafor Edge compatibility. This slice was taken from MYMR-167's scope as a hard prerequisite; MYMR-167's remaining work shrinks to wiringCloudflareRateLimitBackendto the rate-limit bindings + integratingwithRequestDbat the request gate.What is not in this PR (deferred to downstream tasks):
PLACEHOLDER_*IDs inwrangler.jsoncwith provisioned R2 / KV / D1 / DO IDs and attaches theapp.mymir.devcustom domain.CloudflareRateLimitBackendand invokeswithRequestDbat the middleware request gate.secondaryStorageadapter.MymirBrokerDO ships as a skeleton (fetchreturns 501). A follow-up task implements the full pub/sub fanout once MYMR-165 provisions the DO and MYMR-167 wires the realtime path through the middleware.Deviations from the original implementation plan (recorded in the executionRecord on MYMR-164):
NEXT_INC_CACHE_R2. OpenNext'sr2-incremental-cacheoverride hard-codesNEXT_INC_CACHE_R2_BUCKET(seenode_modules/@opennextjs/cloudflare/dist/api/overrides/incremental-cache/r2-incremental-cache.js:6). Used the override's expected name; MYMR-165 should match.app/api/mcp/route.ts. BetterAuth'sverifyJwsAccessToken(@better-auth/core/dist/oauth2/verify.mjs:7,32-36) already caches JWKS at module scope; no code change needed.scripts/postbuild-cf.tsbundlesbroker-do.tsand appends the export toworker.jsafter eachopennextjs-cloudflare build.Type of change
Testing
bun run devbun run lint)bun run typecheck)bun run format:checkpassesbun run buildproduces.next/standalone(output: "standalone"only emitted whenDEPLOY_TARGETis unset)bun run build:cfproduces.open-next/worker.jscarrying@neondatabase/serverlessand zeropostgres-jsreferences (grep -c "postgresjs_" .open-next/server-functions/default/handler.mjsreturns 0;grep -c "@neondatabase\|NeonPool\|neon-serverless"returns 2)MymirBrokeris exported from.open-next/worker.jsand bundled into.open-next/.build/durable-objects/mymir-broker.jsEnd-to-end verification under
wrangler devis deferred to MYMR-165 because thePLACEHOLDER_*IDs inwrangler.jsoncare not yet provisioned. That gate runs as part of MYMR-165.Notes for reviewer
resolve.aliaswhich did not reliably match relative imports of the indirection files. Switched toNormalModuleReplacementPluginwith a regex on the import specifier. Verified the plugin fires consistently against.workersfor both the leadingnext buildpass and OpenNext's internalnext buildpass — providedDEPLOY_TARGET=cloudflareis set on every command in the chain.{ pool, db }with a portableClosablePool = { end: () => Promise<unknown> }shape so the seeding helper canpool.end()without depending on driver-specific Pool types.cloudflare-env.d.tsis committed but excluded from tsconfig include / biome / eslint because its inline workers-types declarations override DOMResponse/Requestshapes and break unrelated tests. Local stubs in the four Workers-only source files cover the DO types we actually use.withRequestDbhelper is exported but not yet invoked. MYMR-167 wires it into the middleware request gate where every route handler will run inside awithRequestDb(() => ...)frame.